Skip to content

feat(node): iCaptcha proof-of-intelligence gate on create_repo + register#108

Open
kevincodex1 wants to merge 6 commits into
mainfrom
feat/icaptcha-gate
Open

feat(node): iCaptcha proof-of-intelligence gate on create_repo + register#108
kevincodex1 wants to merge 6 commits into
mainfrom
feat/icaptcha-gate

Conversation

@kevincodex1

@kevincodex1 kevincodex1 commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an optional iCaptcha "proof-of-intelligence" gate on the two spam-prone
endpoints (create_repo, register). Callers present an Ed25519-signed proof
(earned by solving an escalating challenge at icaptcha.gitlawb.com); the node
verifies it OFFLINE with ed25519-dalek against the published public key and binds
it to the authenticated agent DID so proofs cannot be shared. Motivated by the
did_key spam flood that filled a node's disk.

Impact / compatibility

  • Backward-compatible by default. ICAPTCHA_MODE is off unless set, so merging
    and deploying changes nothing observable. No schema changes, no response-shape
    changes, and git push (receive-pack) is intentionally NOT gated.
  • shadow: verifies and logs would-be rejections, always allows (safe to tune).
  • enforce: rejects create_repo/register without a valid, strong-enough proof.
    This is the only breaking mode and is opt-in; roll out client proof support and
    verify via shadow first.
  • Fail safe: if the public key cannot be loaded at startup (iCaptcha unreachable),
    the gate stays inert rather than rejecting everyone.

Config (env)

  • ICAPTCHA_MODE = off | shadow | enforce (default off)
  • ICAPTCHA_URL (default https://icaptcha.gitlawb.com)
  • ICAPTCHA_PUBKEY (optional base64url override; else fetched from /v1/pubkey)
  • ICAPTCHA_REQUIRED_LEVEL (default 3)

Clients send the proof in the X-ICaptcha-Proof header.

Testing

  • cargo test -p gitlawb-node icaptcha — 11 tests pass, including offline
    verification of a REAL captured proof (JS-signed, Rust-verified),
    tamper/expiry/wrong-DID/insufficient-level/missing-header rejections, and
    the off/shadow/enforce/fail-safe mode matrix.
  • cargo fmt, no new warnings. Cargo.lock diff is a single base64 line.

Notes / follow-ups

  • This raises the cost of abuse but is not the hard cap. Recommend adding
    per-DID repo/creation quotas in the node as the absolute ceiling.
  • Rollout: deploy with mode unset (inert) -> shadow (observe) -> enforce.

Address #100

Summary by CodeRabbit

  • New Features
    • Added an optional iCaptcha “proof-of-intelligence” gate for registration and repository creation with off, shadow, and enforce modes plus DID-level validation.
    • Enforced proofs can’t be reused (tracked and spent on success).
  • Configuration
    • Updated .env.example with ICAPTCHA_MODE, ICAPTCHA_URL, ICAPTCHA_REQUIRED_LEVEL, and optional ICAPTCHA_PUBKEY.
  • Bug Fixes
    • Deferred proof spending until after early request checks pass.
  • Tests
    • Added unit tests for gate modes and validation/consume behavior.
  • Chores
    • Added a ledger table and periodic cleanup of expired proof entries.

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an icaptcha module that loads a verification key, validates x-icaptcha-proof headers in off, shadow, or enforce mode, records consumed proofs in the database, and applies the check during startup and before register and create_repo requests.

Changes

iCaptcha Proof Gate

Layer / File(s) Summary
Module, key loading, and proof verification
crates/gitlawb-node/src/icaptcha.rs, crates/gitlawb-node/Cargo.toml
Defines mode parsing, required-level parsing, verifier state, key loading, global initialization, proof checking, and verification of signed headers and claims with unit tests. Adds base64 as a workspace dependency.
Proof replay ledger
crates/gitlawb-node/src/db/mod.rs
Adds the icaptcha_consumed_proofs migration and database methods for recording proof ids and sweeping expired entries.
Startup initialization and sweep
crates/gitlawb-node/src/main.rs
Declares the icaptcha module in the binary, runs initialization during startup, and extends the background task to sweep expired proofs.
Register and repo enforcement
crates/gitlawb-node/src/api/register.rs, crates/gitlawb-node/src/api/repos.rs
Extends the register and create_repo handlers to read request headers, verify proofs before request admissibility checks, and consume proofs before the first write.
Proof-required error and config
crates/gitlawb-node/src/error.rs, .env.example
Adds the IcaptchaProofRequired response mapping and documents the iCaptcha environment variables in the example configuration.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Gitlawb/node#87: Both PRs modify the register handler in gitlawb-node; this one adds an iCaptcha header gate before the existing registration flow.

Suggested labels

crate:node, kind:feature, subsystem:identity, sev:medium

Poem

🐇 A proof in the header, a hop in the light,
Signed by the key and checked through the night.
The gate keeps its watch as requests roll by,
And rabbits approve when the claims satisfy.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly names the main feature and the two affected endpoints.
Description check ✅ Passed The PR description covers summary, impact, config, testing, and follow-ups, so it is mostly complete despite missing some template sections.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/icaptcha-gate

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/gitlawb-node/src/icaptcha.rs`:
- Around line 51-56: The parse_mode helper in icaptcha.rs currently maps any
unrecognized ICAPTCHA_MODE value to Mode::Off, which silently disables
enforcement. Update parse_mode (and the other call site referenced by the
comment) so invalid inputs are detected and surfaced with a warning or error log
before falling back, using the existing Mode enum and the icaptcha configuration
path to keep operator-visible signal for typos like “enforced”.
- Around line 98-101: The startup key fetch in fetch_key is currently using
reqwest::get without any timeout, so a hung /v1/pubkey endpoint can block
icaptcha::init during startup. Update fetch_key to use a reqwest client or
request builder with a timeout for the pubkey request, and keep the existing
fallback behavior so init can return inert when the fetch does not complete.
Make the change in the fetch_key path used by icaptcha::init and main startup,
preserving the current JWKS parsing and decode_key flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8d94b947-513c-42cd-ad24-cecbc38170e7

📥 Commits

Reviewing files that changed from the base of the PR and between 6809201 and 2e258ee.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • crates/gitlawb-node/Cargo.toml
  • crates/gitlawb-node/src/api/register.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/icaptcha.rs
  • crates/gitlawb-node/src/main.rs

Comment thread crates/gitlawb-node/src/icaptcha.rs Outdated
Comment thread crates/gitlawb-node/src/icaptcha.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.env.example:
- Around line 98-105: Reorder the iCaptcha environment entries in the example so
dotenv-linter no longer flags them: move ICAPTCHA_PUBKEY and
ICAPTCHA_REQUIRED_LEVEL above ICAPTCHA_URL while keeping the existing
ICAPTCHA_MODE and related comments intact. Update the .env.example block in
place so the key ordering is consistent with the linter’s expected
alphabetical-style grouping for the ICAPTCHA_* variables.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3afe1e80-c2ef-4e52-8b1e-45e48ece29f3

📥 Commits

Reviewing files that changed from the base of the PR and between 2e258ee and 4f39baf.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • .env.example
  • crates/gitlawb-node/Cargo.toml
  • crates/gitlawb-node/src/api/register.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/icaptcha.rs
  • crates/gitlawb-node/src/main.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/gitlawb-node/Cargo.toml
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/main.rs
  • crates/gitlawb-node/src/api/register.rs
  • crates/gitlawb-node/src/icaptcha.rs

Comment thread .env.example

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [P2] Complete CodeRabbit's request to surface invalid iCaptcha modes
    crates/gitlawb-node/src/icaptcha.rs:51
    CodeRabbit's inline request is still valid: parse_mode maps every unrecognized ICAPTCHA_MODE value to Mode::Off, so an operator typo such as ICAPTCHA_MODE=enforced silently disables the protection they were trying to enable. Please complete that review request by accepting only the documented values and logging or otherwise surfacing invalid input before falling back.

  • [P2] Complete CodeRabbit's request to bound startup key fetches
    crates/gitlawb-node/src/icaptcha.rs:98
    CodeRabbit's startup-availability request is still valid: main awaits icaptcha::init(), and fetch_key uses reqwest::get() without a timeout. With ICAPTCHA_MODE=shadow or enforce, a hung /v1/pubkey endpoint can leave the node stuck during startup instead of reaching the intended fail-safe inert path. Please complete that review request by adding a bounded request timeout while preserving the current fallback behavior.

  • [P2] Surface invalid required-level configuration instead of lowering to the default
    crates/gitlawb-node/src/icaptcha.rs:110
    ICAPTCHA_REQUIRED_LEVEL is parsed with .and_then(|v| v.parse().ok()).unwrap_or(3), so an operator trying to raise the gate with a typo such as ICAPTCHA_REQUIRED_LEVEL=5 or ICAPTCHA_REQUIRED_LEVEL=five silently runs at level 3. In enforce mode that weakens the configured challenge threshold with no warning. Please validate and warn or reject invalid level values instead of defaulting silently.

  • [P3] Complete CodeRabbit's dotenv-linter request for the new env block
    .env.example:101
    CodeRabbit's later lint comment is still current: the new block leaves ICAPTCHA_URL before ICAPTCHA_PUBKEY and ICAPTCHA_REQUIRED_LEVEL, and its dotenv-linter output flags both as UnorderedKey. Please complete that review request by reordering the new ICAPTCHA_* entries so the example stops carrying the lint warning.

kevincodex1 added a commit that referenced this pull request Jun 27, 2026
…y fetch)

Addresses CodeRabbit and @jatmn review feedback on #108:

- parse_mode now returns Option and rejects unrecognized ICAPTCHA_MODE values;
  init() warns and falls back to off instead of silently disabling the gate on
  a typo like "enforced". [P2]
- ICAPTCHA_REQUIRED_LEVEL: warn on a non-empty unparseable value instead of
  silently lowering the threshold to the default. [P2]
- fetch_key uses a reqwest client with a 5s timeout so a hung /v1/pubkey can
  never block node startup; still fails inert on timeout/error. [P2]
- .env.example: reorder ICAPTCHA_* keys (MODE, PUBKEY, REQUIRED_LEVEL, URL) to
  satisfy dotenv-linter. [P3]
- add parse_mode unit test (rejects junk, accepts documented values).

cargo test -p gitlawb-node icaptcha: 12 passed. clippy + fmt clean.

Author: Kevin Codex <kevin@gitlawb.com>

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update. I rechecked the changed paths and found one issue that still needs to be addressed.

Findings

  • [P2] Consume proof IDs so one solved challenge cannot be replayed
    crates/gitlawb-node/src/icaptcha.rs:230
    The verifier decodes the signed claims and checks exp, level, and sub, but it does not read or store the proof's jti/nonce, even though the captured proof fixture includes one. That means the same X-ICaptcha-Proof can be reused repeatedly by the same authenticated DID until it expires, including across the spam-prone create_repo and register routes this PR is trying to gate. With the existing per-DID throttle, one solved challenge can still cover every allowed creation request in the window, and new-DID registration remains a replayable bearer proof for that DID. Please consume a unique proof ID, at least until its exp, and reject repeats so enforce actually requires a fresh earned proof for each gated creation action.

kevincodex1 added a commit that referenced this pull request Jun 28, 2026
Addresses @jatmn P2 on #108: the verifier checked exp/level/sub but never
consumed the proof's jti, so one solved challenge was a reusable bearer proof
for that DID until expiry, covering every gated create_repo/register in the
window. Now, in enforce mode, each verified proof's jti is recorded once and
replays are rejected.

- db: migration v7 adds icaptcha_consumed_proofs(jti PK, expires_at); new
  consume_proof_jti (INSERT ON CONFLICT DO NOTHING -> bool) and
  sweep_expired_proofs. DB-backed so it holds across restarts and instances.
- icaptcha: ProofClaims carries jti; verify returns the claims; decide() stays
  pure and returns Allow | Reject | Consume{jti,exp}; check() is now async and
  spends the jti via the DB, rejecting replays (enforce only; shadow observes
  without consuming).
- handlers: create_repo/register await check(&state.db, ...).
- main: periodic sweep of expired proof rows alongside rate-limit cleanup.
- tests: 12 pass incl. decide() yields Consume with the proof's jti.

Author: Kevin Codex <kevin@gitlawb.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/gitlawb-node/src/icaptcha.rs`:
- Around line 184-205: The `check` flow in `icaptcha.rs` consumes the proof
`jti` too early, before the request is fully known to be admissible. Update
`check`/`Decision::Consume` handling so `register` and `create_repo` can verify
the proof first and only call the consumption path after DID/name/existence
validation has passed, or split verification from consumption entirely. Use the
existing `check`, `decide`, `Decision::Consume`, and `db.consume_proof_jti`
symbols to keep the replay protection but avoid burning valid proofs on rejected
requests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2fc420e5-6ba2-43cc-8169-8ccd147985d6

📥 Commits

Reviewing files that changed from the base of the PR and between edfd0fa and 8cfa65a.

📒 Files selected for processing (5)
  • crates/gitlawb-node/src/api/register.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/icaptcha.rs
  • crates/gitlawb-node/src/main.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/gitlawb-node/src/api/register.rs
  • crates/gitlawb-node/src/api/repos.rs

Comment thread crates/gitlawb-node/src/icaptcha.rs Outdated
…ster

Add an optional gate that requires callers to present an iCaptcha proof (an
Ed25519-signed token from icaptcha.gitlawb.com, earned by solving an escalating
challenge) on the two spam-prone endpoints. Proofs are verified OFFLINE with
ed25519-dalek against the published public key, and bound to the authenticated
agent DID so they cannot be shared.

Backward-compatible by default: ICAPTCHA_MODE is `off` unless set, so deploying
changes nothing. `shadow` verifies and logs would-be rejections but always
allows; `enforce` rejects requests lacking a valid, strong-enough proof. If the
public key cannot be loaded at startup the gate stays inert (fail safe) so an
iCaptcha outage can never break repo creation or registration.

- New src/icaptcha.rs: env-driven verifier (OnceLock), /v1/pubkey fetch at
  startup or ICAPTCHA_PUBKEY override, proof parse + signature + expiry + level
  + DID-binding checks; mode-aware decide() split out for testing.
- Wired into create_repo and register (X-ICaptcha-Proof header). git push
  (receive-pack) is intentionally NOT gated.
- 11 unit tests incl. verification of a real captured proof (JS-signed,
  Rust-verified), tamper/expiry/wrong-DID/level/missing-header rejections, and
  the off/shadow/enforce/fail-safe mode matrix.

Author: Kevin Codex <kevin@gitlawb.com>
…y fetch)

Addresses CodeRabbit and @jatmn review feedback on #108:

- parse_mode now returns Option and rejects unrecognized ICAPTCHA_MODE values;
  init() warns and falls back to off instead of silently disabling the gate on
  a typo like "enforced". [P2]
- ICAPTCHA_REQUIRED_LEVEL: warn on a non-empty unparseable value instead of
  silently lowering the threshold to the default. [P2]
- fetch_key uses a reqwest client with a 5s timeout so a hung /v1/pubkey can
  never block node startup; still fails inert on timeout/error. [P2]
- .env.example: reorder ICAPTCHA_* keys (MODE, PUBKEY, REQUIRED_LEVEL, URL) to
  satisfy dotenv-linter. [P3]
- add parse_mode unit test (rejects junk, accepts documented values).

cargo test -p gitlawb-node icaptcha: 12 passed. clippy + fmt clean.

Author: Kevin Codex <kevin@gitlawb.com>
Addresses @jatmn P2 on #108: the verifier checked exp/level/sub but never
consumed the proof's jti, so one solved challenge was a reusable bearer proof
for that DID until expiry, covering every gated create_repo/register in the
window. Now, in enforce mode, each verified proof's jti is recorded once and
replays are rejected.

- db: migration v7 adds icaptcha_consumed_proofs(jti PK, expires_at); new
  consume_proof_jti (INSERT ON CONFLICT DO NOTHING -> bool) and
  sweep_expired_proofs. DB-backed so it holds across restarts and instances.
- icaptcha: ProofClaims carries jti; verify returns the claims; decide() stays
  pure and returns Allow | Reject | Consume{jti,exp}; check() is now async and
  spends the jti via the DB, rejecting replays (enforce only; shadow observes
  without consuming).
- handlers: create_repo/register await check(&state.db, ...).
- main: periodic sweep of expired proof rows alongside rate-limit cleanup.
- tests: 12 pass incl. decide() yields Consume with the proof's jti.

Author: Kevin Codex <kevin@gitlawb.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/gitlawb-node/src/api/repos.rs (1)

145-165: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Move one-shot proof consumption after cheap request validation.

icaptcha::check records the JTI before repo-name validation and the “already exists” check. In enforce mode, a typo or duplicate repo name burns a valid proof, so the corrected retry can fail as a replay even though no repo was created.

Proposed fix
-    // iCaptcha proof-of-intelligence gate (inert unless ICAPTCHA_MODE is set).
-    crate::icaptcha::check(&state.db, &headers, &auth.0).await?;
-
     // Sanitize name: alphanumeric, hyphens, underscores only
     if !req
         .name
         .chars()
@@
     if state.db.get_repo(&owner_did, &req.name).await?.is_some() {
         return Err(AppError::RepoExists(req.name));
     }
+
+    // iCaptcha proof-of-intelligence gate (inert unless ICAPTCHA_MODE is set).
+    crate::icaptcha::check(&state.db, &headers, &owner_did).await?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/api/repos.rs` around lines 145 - 165, Move the
iCaptcha proof consumption later in the repo creation flow so cheap request
validation happens first. In the create-repo path in `repos.rs`, keep the repo
name sanitization and `state.db.get_repo` existence check before calling
`crate::icaptcha::check`, then only consume the JTI once the request is known to
be valid and non-duplicate. Use the `icaptcha::check` call, `owner_did`, and
`state.db.get_repo` as the key anchors when reordering the logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/gitlawb-node/src/api/repos.rs`:
- Around line 881-882: The webhook payload is hard-coding the ref update
metadata as not forced, which misreports force-pushes to downstream consumers.
Update the payload construction in the repo webhook handling logic around the
ref update object so the forced flag is computed from the old/new SHAs instead
of always set to false. Use the existing update context in repos.rs to determine
whether the change is a non-fast-forward update (for example, checking that both
SHAs are non-zero and the old commit is not an ancestor of the new one) before
setting the forced field.

In `@crates/gitlawb-node/src/db/mod.rs`:
- Around line 785-795: The icaptcha replay ledger is keyed only by jti, so a
proof consumed in one gated action blocks reuse in another even though the
contract is per action. Update the icaptcha_consumed_proofs schema and the
related APIs in crate::icaptcha::check to include an action discriminator (for
example register vs create_repo) and enforce uniqueness on the composite key,
then adjust the insert/lookup logic to scope replay checks by both action and
jti.

---

Outside diff comments:
In `@crates/gitlawb-node/src/api/repos.rs`:
- Around line 145-165: Move the iCaptcha proof consumption later in the repo
creation flow so cheap request validation happens first. In the create-repo path
in `repos.rs`, keep the repo name sanitization and `state.db.get_repo` existence
check before calling `crate::icaptcha::check`, then only consume the JTI once
the request is known to be valid and non-duplicate. Use the `icaptcha::check`
call, `owner_did`, and `state.db.get_repo` as the key anchors when reordering
the logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 76257d7a-d8b1-441c-98a8-f2602b38dad6

📥 Commits

Reviewing files that changed from the base of the PR and between 8cfa65a and e976398.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • .env.example
  • crates/gitlawb-node/Cargo.toml
  • crates/gitlawb-node/src/api/register.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/icaptcha.rs
  • crates/gitlawb-node/src/main.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/gitlawb-node/Cargo.toml
  • .env.example
  • crates/gitlawb-node/src/main.rs
  • crates/gitlawb-node/src/api/register.rs
  • crates/gitlawb-node/src/icaptcha.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/gitlawb-node/src/api/repos.rs (1)

145-165: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Move one-shot proof consumption after cheap request validation.

icaptcha::check records the JTI before repo-name validation and the “already exists” check. In enforce mode, a typo or duplicate repo name burns a valid proof, so the corrected retry can fail as a replay even though no repo was created.

Proposed fix
-    // iCaptcha proof-of-intelligence gate (inert unless ICAPTCHA_MODE is set).
-    crate::icaptcha::check(&state.db, &headers, &auth.0).await?;
-
     // Sanitize name: alphanumeric, hyphens, underscores only
     if !req
         .name
         .chars()
@@
     if state.db.get_repo(&owner_did, &req.name).await?.is_some() {
         return Err(AppError::RepoExists(req.name));
     }
+
+    // iCaptcha proof-of-intelligence gate (inert unless ICAPTCHA_MODE is set).
+    crate::icaptcha::check(&state.db, &headers, &owner_did).await?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/api/repos.rs` around lines 145 - 165, Move the
iCaptcha proof consumption later in the repo creation flow so cheap request
validation happens first. In the create-repo path in `repos.rs`, keep the repo
name sanitization and `state.db.get_repo` existence check before calling
`crate::icaptcha::check`, then only consume the JTI once the request is known to
be valid and non-duplicate. Use the `icaptcha::check` call, `owner_did`, and
`state.db.get_repo` as the key anchors when reordering the logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/gitlawb-node/src/api/repos.rs`:
- Around line 881-882: The webhook payload is hard-coding the ref update
metadata as not forced, which misreports force-pushes to downstream consumers.
Update the payload construction in the repo webhook handling logic around the
ref update object so the forced flag is computed from the old/new SHAs instead
of always set to false. Use the existing update context in repos.rs to determine
whether the change is a non-fast-forward update (for example, checking that both
SHAs are non-zero and the old commit is not an ancestor of the new one) before
setting the forced field.

In `@crates/gitlawb-node/src/db/mod.rs`:
- Around line 785-795: The icaptcha replay ledger is keyed only by jti, so a
proof consumed in one gated action blocks reuse in another even though the
contract is per action. Update the icaptcha_consumed_proofs schema and the
related APIs in crate::icaptcha::check to include an action discriminator (for
example register vs create_repo) and enforce uniqueness on the composite key,
then adjust the insert/lookup logic to scope replay checks by both action and
jti.

---

Outside diff comments:
In `@crates/gitlawb-node/src/api/repos.rs`:
- Around line 145-165: Move the iCaptcha proof consumption later in the repo
creation flow so cheap request validation happens first. In the create-repo path
in `repos.rs`, keep the repo name sanitization and `state.db.get_repo` existence
check before calling `crate::icaptcha::check`, then only consume the JTI once
the request is known to be valid and non-duplicate. Use the `icaptcha::check`
call, `owner_did`, and `state.db.get_repo` as the key anchors when reordering
the logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 76257d7a-d8b1-441c-98a8-f2602b38dad6

📥 Commits

Reviewing files that changed from the base of the PR and between 8cfa65a and e976398.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • .env.example
  • crates/gitlawb-node/Cargo.toml
  • crates/gitlawb-node/src/api/register.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/icaptcha.rs
  • crates/gitlawb-node/src/main.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/gitlawb-node/Cargo.toml
  • .env.example
  • crates/gitlawb-node/src/main.rs
  • crates/gitlawb-node/src/api/register.rs
  • crates/gitlawb-node/src/icaptcha.rs
🛑 Comments failed to post (2)
crates/gitlawb-node/src/api/repos.rs (1)

881-882: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don’t hard-code force-push metadata.

Webhook payloads always emit "forced": false, so force pushes are misreported to downstream integrations. Compute this per ref, e.g. old != ZERO_SHA && new != ZERO_SHA && old is not an ancestor of new.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/api/repos.rs` around lines 881 - 882, The webhook
payload is hard-coding the ref update metadata as not forced, which misreports
force-pushes to downstream consumers. Update the payload construction in the
repo webhook handling logic around the ref update object so the forced flag is
computed from the old/new SHAs instead of always set to false. Use the existing
update context in repos.rs to determine whether the change is a non-fast-forward
update (for example, checking that both SHAs are non-zero and the old commit is
not an ancestor of the new one) before setting the forced field.
crates/gitlawb-node/src/db/mod.rs (1)

785-795: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Scope replay tracking per gated action, not globally per jti.

crate::icaptcha::check consumes proofs from both /register and /create_repo, but this ledger makes jti globally unique. After a successful register, the same still-valid proof will be rejected on create_repo, which breaks the "spent once per gated action" contract described here. Add an action discriminator to the table/API and make the uniqueness check composite (for example (action, jti)).

Also applies to: 1115-1129

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/db/mod.rs` around lines 785 - 795, The icaptcha
replay ledger is keyed only by jti, so a proof consumed in one gated action
blocks reuse in another even though the contract is per action. Update the
icaptcha_consumed_proofs schema and the related APIs in crate::icaptcha::check
to include an action discriminator (for example register vs create_repo) and
enforce uniqueness on the composite key, then adjust the insert/lookup logic to
scope replay checks by both action and jti.

@kevincodex1

Copy link
Copy Markdown
Contributor Author

@coderabbitai can you do a full review on this ?

@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

@kevincodex1 Yep — I’ll do a full review of #108 now.

✅ Action performed

Full review finished.

Addresses CodeRabbit (Major) on #108: check() spent the jti as soon as the proof
verified, but create_repo/register ran it before name/DID/existence checks, so a
rejected request (bad name, already-exists, wrong DID) permanently burned a
valid proof without the action succeeding.

Split verification from consumption:
- icaptcha: replace check() with verify_request() -> ProofGuard (verifies and
  rejects invalid/missing proofs early; off/shadow/inert yield a no-op guard)
  plus ProofGuard::consume(db) which spends the jti right before the write and
  rejects replays. Guard is #[must_use].
- create_repo/register: verify_request() up front, then consume() immediately
  before the first write, after cheap validation passes.

Replay protection unchanged (consume-before-write). 12 tests pass; clippy clean.

Author: Kevin Codex <kevin@gitlawb.com>
@kevincodex1

Copy link
Copy Markdown
Contributor Author

hey @coderabbitai please do a full review again and approve if its all good

@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

@kevincodex1 I’ll run a full review on #108 again now. If it comes back clean, I can approve after that.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/gitlawb-node/src/icaptcha.rs`:
- Around line 200-205: The iCaptcha failure paths in consume and the other
matching branch are still returning AppError::Unauthorized, which gets mapped to
the not_an_agent code. Update the iCaptcha flow in icaptcha::consume to use a
dedicated AppError variant for missing/replayed proofs, and add the
corresponding mapping in error.rs so these cases return the new
iCaptcha-specific API code instead of the authn one.
🪄 Autofix (Beta)

❌ Autofix failed (check again to retry)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 65edad93-b5f5-43a3-80c0-f16c17fb97f9

📥 Commits

Reviewing files that changed from the base of the PR and between 6ae316c and 269d609.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • .env.example
  • crates/gitlawb-node/Cargo.toml
  • crates/gitlawb-node/src/api/register.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/icaptcha.rs
  • crates/gitlawb-node/src/main.rs

Comment thread crates/gitlawb-node/src/icaptcha.rs Outdated
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

⚠️ Branch updated during autofix.

The branch was updated while autofix was in progress. Please try again.

Addresses CodeRabbit on #108: iCaptcha failure paths returned
AppError::Unauthorized, which maps to the "not_an_agent" API code and conflates
a valid-but-unproven agent with a failed signature. Add a dedicated
AppError::IcaptchaProofRequired variant mapped to the "icaptcha_proof_required"
code (HTTP 401), and use it for both the missing/invalid-proof and replay paths
so clients can distinguish "solve a challenge" from "your signature is invalid".

12 tests pass; clippy + fmt clean.

Author: Kevin Codex <kevin@gitlawb.com>
@kevincodex1

Copy link
Copy Markdown
Contributor Author

@coderabbitai please make a full review again . approve if all good

@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

@kevincodex1 I’ll run a fresh full review on #108 again now. If it comes back clean, I can approve it after that.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/gitlawb-node/src/icaptcha.rs`:
- Around line 221-224: Shadow mode is dropping the verified proof ID, so replay
detection never runs in the “would reject” path. Update the flow around decide,
ProofGuard, and the shadow-mode handling in the verification path so a
successful shadow verification still carries the jti forward instead of
collapsing to ProofGuard(None). Then make register and create_repo route that
proof through a read-only replay check/log path, preserving consume behavior
only for enforce mode and leaving shadow mode to report reused jtis without
mutating state.
🪄 Autofix (Beta)

✅ Autofix completed


ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4435a3b7-fcb4-4611-a4e5-33514e1824d3

📥 Commits

Reviewing files that changed from the base of the PR and between 6ae316c and 98121ca.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • .env.example
  • crates/gitlawb-node/Cargo.toml
  • crates/gitlawb-node/src/api/register.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/error.rs
  • crates/gitlawb-node/src/icaptcha.rs
  • crates/gitlawb-node/src/main.rs

Comment thread crates/gitlawb-node/src/icaptcha.rs Outdated
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Fixes Applied Successfully

Fixed 2 file(s) based on 1 unresolved review comment.

Files modified:

  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/icaptcha.rs

Commit: 66e7a346738221aed99781969f7cfd6cd88358b1

The changes have been pushed to the feat/icaptcha-gate branch.

Time taken: 5m 47s

Fixed 2 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

@beardthelion beardthelion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the merged state at 66e7a34 (CI green, merge clean). The core gate is sound and I confirmed the load-bearing parts by execution: Ed25519 verify_strict, the proof-to-DID binding via the shared did_matches normalizer (handles short/full did:key, not a bare ==), atomic single-use through INSERT ... ON CONFLICT DO NOTHING, the verify-then-consume split, and that the gate is additive to the existing owner/auth checks (INV-1 intact). Migration 8 is the correct next version, additive and idempotent. The CodeRabbit threads and the two earlier change requests are all addressed on this head, including the shadow read-only replay path.

Two things to close before merge, then a few notes.

Findings

  • [P2] Gate fork_repo, or state why it is exempt
    crates/gitlawb-node/src/api/repos.rs:1420
    fork_repo creates a repo (db.create_repo after a git clone --mirror) and never calls icaptcha::verify_request; its handler signature does not even take HeaderMap. Under enforce an agent bypasses the create_repo/register gate entirely by forking. It is the third repo-creation entrypoint and the one that previously drifted on visibility (#98). create_repo and register are gated and GraphQL exposes no creation mutation, so fork is the only open path. Either thread the gate through it (verify before the clone, consume just before db.create_repo) or document the exemption with the reasoning.

  • [P2] Add DB-level tests for the single-use ledger
    crates/gitlawb-node/src/db/mod.rs:1119
    consume_proof_jti, check_proof_jti, sweep_expired_proofs, and ProofGuard::consume have no execution coverage, although a #[sqlx::test] harness already exists in this same file. Single use is the feature's security value, and right now nothing runs it. The path itself is correct (I checked: first consume succeeds, replay returns false, check_proof_jti does not mutate, sweep keeps still-valid rows and drops expired), so this is just landing those as committed tests.

Notes

  • [P3] Document ICAPTCHA_PUBKEY as the supported enforce configuration
    crates/gitlawb-node/src/icaptcha.rs:246
    decide() returns Allow when no key loaded, so an unreachable /v1/pubkey at startup degrades enforce to inert with only a warn. That is a reasonable availability choice, but it means enforce fails open on a startup network blip. Pinning ICAPTCHA_PUBKEY removes the startup fetch dependency; worth calling it out as the way to run enforce.

  • [P3] Consider 403 rather than 401 for IcaptchaProofRequired
    crates/gitlawb-node/src/error.rs:60
    The caller is already HTTP-signature authenticated; what is missing is an anti-abuse proof, which is closer to 403 Forbidden than 401. The distinct icaptcha_proof_required code is right; only the status is debatable. Confirm no client keys on the numeric 401 before changing it.

  • [P3] Confirm the challenge has a headless solve path before flipping enforce
    The verification side is agent-clean (header-carried, offline, DID-bound). But if icaptcha.gitlawb.com requires interactive human solving, enabling enforce locks autonomous agents out of repo creation and registration, which are the two core write paths. Worth stating the programmatic acquisition flow in the PR or a linked doc.

  • [P3] Pure-shadow replay detection is effectively inert
    crates/gitlawb-node/src/icaptcha.rs
    Shadow now routes to the read-only check_proof_jti, but shadow never writes the ledger, so in a shadow-only deployment nothing is ever recorded and the replay warning cannot fire. It only does anything in a mixed shadow+enforce setup. Minor, just flagging the limited value of the new path on its own.

Core is solid; the only real gap is the ungated fork path. Once fork_repo is covered (or consciously exempted) and the ledger has tests, this is good to go.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Gate fork_repo, or explicitly document why it is exempt
crates/gitlawb-node/src/api/repos.rs:1420
fork_repo still creates a repository with state.db.create_repo(&record).await?, but unlike create_repo and register its handler does not take HeaderMap and never calls crate::icaptcha::verify_request. In ICAPTCHA_MODE=enforce, an authenticated agent can therefore bypass the new anti-abuse gate by forking any readable repo instead of calling POST /api/v1/repos, even though this route is in the same rate-limited creation route group and produces a new repo row. Please either thread the iCaptcha proof through this path as well, consuming it immediately before db.create_repo, or document the intentional exemption and why fork creation is not part of this abuse boundary.

[P2] Add execution coverage for the replay ledger
crates/gitlawb-node/src/db/mod.rs:1119
The security value of the gate now depends on the new DB-backed single-use ledger, but the committed tests only exercise the pure verifier/decision logic. consume_proof_jti, check_proof_jti, sweep_expired_proofs, and the ProofGuard::consume path are not executed by any committed test even though this file already has a #[sqlx::test] harness. Please add DB-level coverage that proves first consume succeeds, replay returns false, the shadow read-only check does not mutate state, and sweeping removes only expired proof rows.

@kevincodex1

Copy link
Copy Markdown
Contributor Author

@coderabbitai please do a full review again. approve only if all is good

@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

@kevincodex1 I’ll run a fresh full review on #108 now and only approve it if everything looks good.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/gitlawb-node/src/db/mod.rs`:
- Around line 1132-1143: `check_proof_jti` is currently read-only, so
shadow-mode requests never get recorded and repeated proofs keep appearing
fresh. Update the shadow-mode caller that uses `check_proof_jti` to first record
the proof as consumed on the first seen request, then use the check only to
decide whether to log a would-be replay rejection while still allowing the
request through. Keep the replay-detection behavior centered around
`check_proof_jti` and the downstream shadow path so the first-seen proof is
persisted before subsequent shadow checks.

In `@crates/gitlawb-node/src/icaptcha.rs`:
- Around line 207-224: In `icaptcha::consume`, the shadow-mode branch currently
uses `db.check_proof_jti(...).await?`, which lets transient DB errors bubble up
and fail `register`/`create_repo` even though shadow mode should only log.
Change the `else` path to handle `check_proof_jti` errors locally: log the
failure with `tracing` (including `job.jti`), treat it as non-fatal, and
continue returning `Ok(())` so shadow mode never fails closed; keep the enforced
replay rejection path unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2796eb33-736f-4cc2-8a12-33657b6b2ab0

📥 Commits

Reviewing files that changed from the base of the PR and between 6ae316c and 66e7a34.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • .env.example
  • crates/gitlawb-node/Cargo.toml
  • crates/gitlawb-node/src/api/register.rs
  • crates/gitlawb-node/src/api/repos.rs
  • crates/gitlawb-node/src/db/mod.rs
  • crates/gitlawb-node/src/error.rs
  • crates/gitlawb-node/src/icaptcha.rs
  • crates/gitlawb-node/src/main.rs

Comment on lines +1132 to +1143
/// Read-only check if an iCaptcha proof id (`jti`) has already been consumed.
/// Returns `Ok(true)` if the jti is fresh (not yet consumed), `Ok(false)` if
/// it is a replay. Used by shadow mode to detect replays without mutating state.
pub async fn check_proof_jti(&self, jti: &str) -> Result<bool> {
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM icaptcha_consumed_proofs WHERE jti = $1)",
)
.bind(jti)
.fetch_one(&self.pool)
.await?;
Ok(!exists)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Record first-seen proofs in shadow mode before checking replay.

check_proof_jti is read-only, and the downstream shadow path only calls this method, so repeated shadow-mode uses remain absent from icaptcha_consumed_proofs and keep reporting as fresh. That misses the PR goal of logging would-be replay rejections.

Suggested direction
-    pub async fn check_proof_jti(&self, jti: &str) -> Result<bool> {
-        let exists = sqlx::query_scalar::<_, bool>(
-            "SELECT EXISTS(SELECT 1 FROM icaptcha_consumed_proofs WHERE jti = $1)",
-        )
-        .bind(jti)
-        .fetch_one(&self.pool)
-        .await?;
-        Ok(!exists)
+    pub async fn observe_proof_jti(&self, jti: &str, expires_at: i64) -> Result<bool> {
+        self.consume_proof_jti(jti, expires_at).await
     }

Then update the shadow caller to log on false but still allow the request.

-                let is_fresh = db.check_proof_jti(&job.jti).await?;
+                let is_fresh = db.observe_proof_jti(&job.jti, job.exp).await?;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Read-only check if an iCaptcha proof id (`jti`) has already been consumed.
/// Returns `Ok(true)` if the jti is fresh (not yet consumed), `Ok(false)` if
/// it is a replay. Used by shadow mode to detect replays without mutating state.
pub async fn check_proof_jti(&self, jti: &str) -> Result<bool> {
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM icaptcha_consumed_proofs WHERE jti = $1)",
)
.bind(jti)
.fetch_one(&self.pool)
.await?;
Ok(!exists)
}
/// Read-only check if an iCaptcha proof id (`jti`) has already been consumed.
/// Returns `Ok(true)` if the jti is fresh (not yet consumed), `Ok(false)` if
/// it is a replay. Used by shadow mode to detect replays without mutating state.
pub async fn observe_proof_jti(&self, jti: &str, expires_at: i64) -> Result<bool> {
self.consume_proof_jti(jti, expires_at).await
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/db/mod.rs` around lines 1132 - 1143,
`check_proof_jti` is currently read-only, so shadow-mode requests never get
recorded and repeated proofs keep appearing fresh. Update the shadow-mode caller
that uses `check_proof_jti` to first record the proof as consumed on the first
seen request, then use the check only to decide whether to log a would-be replay
rejection while still allowing the request through. Keep the replay-detection
behavior centered around `check_proof_jti` and the downstream shadow path so the
first-seen proof is persisted before subsequent shadow checks.

Comment on lines +207 to +224
pub async fn consume(self, db: &crate::db::Db) -> Result<(), AppError> {
if let Some(job) = self.0 {
if job.enforce {
// Enforce mode: consume the jti (mutate state) and reject replays.
if !db.consume_proof_jti(&job.jti, job.exp).await? {
return Err(AppError::IcaptchaProofRequired(
"iCaptcha proof already used (replay); solve a fresh challenge".to_string(),
));
}
} else {
// Shadow mode: read-only replay check, log reuse but do not block.
let is_fresh = db.check_proof_jti(&job.jti).await?;
if !is_fresh {
tracing::warn!(jti = %job.jti, "iCaptcha (shadow) proof replay detected");
}
}
}
Ok(())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Don't let shadow mode fail closed on replay-check errors.

Lines 218-219 still propagate db.check_proof_jti(...) failures with ?. Because both gated handlers call proof.consume(&state.db).await?, a transient DB failure will reject register/create_repo in shadow mode even though this mode is supposed to log and still allow.

Suggested fix
     pub async fn consume(self, db: &crate::db::Db) -> Result<(), AppError> {
         if let Some(job) = self.0 {
             if job.enforce {
                 // Enforce mode: consume the jti (mutate state) and reject replays.
                 if !db.consume_proof_jti(&job.jti, job.exp).await? {
                     return Err(AppError::IcaptchaProofRequired(
                         "iCaptcha proof already used (replay); solve a fresh challenge".to_string(),
                     ));
                 }
             } else {
                 // Shadow mode: read-only replay check, log reuse but do not block.
-                let is_fresh = db.check_proof_jti(&job.jti).await?;
-                if !is_fresh {
-                    tracing::warn!(jti = %job.jti, "iCaptcha (shadow) proof replay detected");
+                match db.check_proof_jti(&job.jti).await {
+                    Ok(false) => {
+                        tracing::warn!(jti = %job.jti, "iCaptcha (shadow) proof replay detected");
+                    }
+                    Ok(true) => {}
+                    Err(error) => {
+                        tracing::warn!(
+                            jti = %job.jti,
+                            error = %error,
+                            "iCaptcha (shadow) replay check failed; allowing request"
+                        );
+                    }
                 }
             }
         }
         Ok(())
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub async fn consume(self, db: &crate::db::Db) -> Result<(), AppError> {
if let Some(job) = self.0 {
if job.enforce {
// Enforce mode: consume the jti (mutate state) and reject replays.
if !db.consume_proof_jti(&job.jti, job.exp).await? {
return Err(AppError::IcaptchaProofRequired(
"iCaptcha proof already used (replay); solve a fresh challenge".to_string(),
));
}
} else {
// Shadow mode: read-only replay check, log reuse but do not block.
let is_fresh = db.check_proof_jti(&job.jti).await?;
if !is_fresh {
tracing::warn!(jti = %job.jti, "iCaptcha (shadow) proof replay detected");
}
}
}
Ok(())
pub async fn consume(self, db: &crate::db::Db) -> Result<(), AppError> {
if let Some(job) = self.0 {
if job.enforce {
// Enforce mode: consume the jti (mutate state) and reject replays.
if !db.consume_proof_jti(&job.jti, job.exp).await? {
return Err(AppError::IcaptchaProofRequired(
"iCaptcha proof already used (replay); solve a fresh challenge".to_string(),
));
}
} else {
// Shadow mode: read-only replay check, log reuse but do not block.
match db.check_proof_jti(&job.jti).await {
Ok(false) => {
tracing::warn!(jti = %job.jti, "iCaptcha (shadow) proof replay detected");
}
Ok(true) => {}
Err(error) => {
tracing::warn!(
jti = %job.jti,
error = %error,
"iCaptcha (shadow) replay check failed; allowing request"
);
}
}
}
}
Ok(())
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/icaptcha.rs` around lines 207 - 224, In
`icaptcha::consume`, the shadow-mode branch currently uses
`db.check_proof_jti(...).await?`, which lets transient DB errors bubble up and
fail `register`/`create_repo` even though shadow mode should only log. Change
the `else` path to handle `check_proof_jti` errors locally: log the failure with
`tracing` (including `job.jti`), treat it as non-fatal, and continue returning
`Ok(())` so shadow mode never fails closed; keep the enforced replay rejection
path unchanged.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Complete the request to gate fork_repo or document its exemption
crates/gitlawb-node/src/api/repos.rs:1420
Jatmn's change request is still valid on the current head: fork_repo is in the same rate-limited creation route group as create_repo and register, and it still creates a new repo row with state.db.create_repo(&record).await?, but the handler does not take HeaderMap and never calls crate::icaptcha::verify_request. In ICAPTCHA_MODE=enforce, an authenticated agent can bypass the anti-abuse gate by forking any readable repo instead of calling POST /api/v1/repos. Please complete that review request by threading the iCaptcha proof through this path and consuming it immediately before db.create_repo, or explicitly document why fork creation is outside this abuse boundary.

[P2] Add committed execution coverage for the replay ledger
crates/gitlawb-node/src/db/mod.rs:1119
Jatmn's replay-ledger coverage request is also still open. The committed tests exercise the pure verifier/decision logic, but consume_proof_jti, check_proof_jti, sweep_expired_proofs, and the ProofGuard::consume path are not executed by any committed test even though the file already has a #[sqlx::test] harness. Because the single-use DB ledger is the security boundary that makes proofs non-replayable, please add DB-level tests proving first consume succeeds, replay returns false, the shadow observation path behaves as intended, and sweeping removes only expired proof rows.

[P2] Complete CodeRabbit's request to make shadow replay detection record first-seen proofs
crates/gitlawb-node/src/icaptcha.rs:218
CodeRabbit's current shadow-mode replay request is still valid: the shadow path calls db.check_proof_jti(&job.jti).await?, but that helper is read-only, so a shadow-only deployment never records the first use of a proof. A repeated proof therefore keeps appearing fresh and never produces the "would reject replay" signal that shadow mode is supposed to provide before operators flip to enforce. Please complete that request by recording first-seen proof IDs in the shadow observation path while still allowing the request through.

[P2] Complete CodeRabbit's request to keep shadow replay-check errors non-fatal
crates/gitlawb-node/src/icaptcha.rs:218
The same shadow branch still propagates db.check_proof_jti(...) with ?. Since both gated handlers call proof.consume(&state.db).await?, a transient DB error in shadow mode rejects register or create_repo, even though shadow mode is documented as an observe/log mode that always allows. Please handle replay-check failures locally in the shadow branch, log the jti and error, and return Ok(()) so only enforce mode fails closed on ledger errors.

@beardthelion beardthelion added crate:node gitlawb-node — the serving node and REST API subsystem:api Node REST API request/response surface kind:feature New capability or surface sev:medium Degraded but workaround exists labels Jun 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

crate:node gitlawb-node — the serving node and REST API kind:feature New capability or surface sev:medium Degraded but workaround exists subsystem:api Node REST API request/response surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants